返回文章列表

第九章|Web Audio API 基础

从 AudioContext、AudioNode graph、AudioBuffer、AudioBufferSourceNode、MediaElementAudioSourceNode、GainNode、BiquadFilterNode、AnalyserNode、AudioWorklet 和 OfflineAudioContext 入手,建立浏览器音频处理的基础心智模型。

第九章|Web Audio API 基础

1. 本章学习目标

学完这一章,你要建立一个核心心智模型:

Web Audio 不是“播放音频文件”的 API,而是浏览器里的音频处理引擎。

如果说 <audio> 标签像一个“播放器”,那 Web Audio API 更像一台“调音台 + 效果器机架 + 录音室控制台”。

本章学完后,你应该能做到:

  1. 解释 AudioContext 是什么。
  2. 画出一个简单的 AudioNode graph
  3. 用 Web Audio 播放本地音频文件。
  4. GainNode 做音量控制。
  5. BiquadFilterNode 做简单滤波。
  6. AnalyserNode 做频谱可视化。
  7. 理解 AudioBufferAudioBufferSourceNode 的关系。
  8. 理解 MediaElementAudioSourceNode<audio> / <video> 的关系。
  9. 理解 MediaStreamAudioSourceNode 如何接麦克风。
  10. 知道为什么 ScriptProcessorNode 不推荐,为什么应该关注 AudioWorklet
  11. 使用 OfflineAudioContext 做离线混音和导出 WAV。

W3C 对 Web Audio API 的定位是:它是用于 Web 应用中音频处理和合成的高级 API,核心范式是把多个 AudioNode 连接成一个音频路由图;实际音频处理通常由浏览器底层高效实现完成。(W3C) MDN 也把它描述为一个可以选择音频源、添加效果、做音频可视化、空间化等处理的系统。(MDN Web Docs)


2. 一句话理解 Web Audio

Web Audio 的基本工作方式是:

音频源 Source

处理节点 Processing Node

分析节点 Analyser Node,可选

输出节点 Destination

比如:

本地 MP3 文件
  ↓ decodeAudioData
AudioBuffer

AudioBufferSourceNode

GainNode,控制音量

BiquadFilterNode,滤波

AnalyserNode,拿频谱数据

AudioDestinationNode,扬声器 / 耳机

更像工程里的真实画法:

+-------------------+
|   AudioContext    |
|                   |
|  [Source Node]    |
|        ↓          |
|  [GainNode]       |
|        ↓          |
|  [FilterNode]     |
|        ↓          |
|  [AnalyserNode]   |
|        ↓          |
|  [destination]    |
+-------------------+

MDN 对这个模型的描述也很直观:Web Audio 在一个 audio context 内处理音频操作,基础操作由 audio nodes 完成,节点通过输入和输出连接成 audio routing graph,最终可以连到 destination 输出到扬声器或耳机。(MDN Web Docs)


3. 核心概念解释

3.1 AudioContext:音频世界的运行时

AudioContext 可以理解成浏览器里的“音频引擎实例”。

它负责:

能力说明
创建节点创建 GainNodeAnalyserNodeBiquadFilterNode
管理音频时钟audioCtx.currentTime 是高精度音频时间
管理采样率audioCtx.sampleRate 决定当前上下文的采样率
解码音频decodeAudioData() 可以把完整音频文件解码成 AudioBuffer
输出声音audioCtx.destination 通常代表系统扬声器 / 耳机
控制状态resume()suspend()close() 控制音频上下文生命周期

典型代码:

const audioCtx = new AudioContext();

console.log(audioCtx.sampleRate);
console.log(audioCtx.currentTime);
console.log(audioCtx.state); // "suspended" | "running" | "closed"

工程里通常不要到处创建 AudioContext。一个页面里可以优先复用一个主 AudioContext,否则资源管理和浏览器限制会让你踩坑。


3.2 AudioNode:音频管线里的一个节点

AudioNode 是所有音频节点的共同基类。你可以把每个 node 想成调音台上的一个模块:

输入音频 → 节点处理 → 输出音频

有些节点是“源头”,只输出不输入:

AudioBufferSourceNode
MediaElementAudioSourceNode
MediaStreamAudioSourceNode
OscillatorNode

有些节点是“处理器”,有输入也有输出:

GainNode
BiquadFilterNode
DelayNode
DynamicsCompressorNode
WaveShaperNode
AnalyserNode
AudioWorkletNode

有些节点是“终点”:

AudioDestinationNode
MediaStreamAudioDestinationNode

3.3 AudioDestinationNode:声音最终去哪里

audioCtx.destination 就是默认输出节点,通常代表用户的扬声器或耳机。

source.connect(audioCtx.destination);

如果一个音频 graph 没有连到 destination,用户通常听不到声音;但这不代表 graph 没有用。例如频谱分析、离线渲染、录制、生成 PCM 都可以不直接播放出来。MDN 也明确提到,连接到 destination 只有在用户需要听到声音时才是必要的。(MDN Web Docs)


3.4 AudioBuffer:解码后的 PCM 音频数据

AudioBuffer 可以理解成:

已经解码好的 PCM 音频,存在内存里。

比如一个 MP3 文件:

MP3 文件,压缩数据
  ↓ decodeAudioData
AudioBuffer,解码后的 PCM

AudioBuffer 里面通常包含:

属性含义
sampleRate采样率,例如 44100 / 48000
numberOfChannels声道数,例如 1 / 2
length每个声道的 sample 数
duration时长,单位秒
getChannelData(index)获取某个声道的 Float32 PCM 数据

注意:AudioBuffer 是解码后的 PCM,内存占用会比压缩后的 MP3 / AAC 大很多。

举个例子,3 分钟、48kHz、双声道、Float32 PCM:

48000 samples/s × 180s × 2 channels × 4 bytes
≈ 69 MB

所以 Web Audio 很适合短音效、音乐编辑、混音、可视化,但如果你只是播放一个很长的播客,不一定要把整个文件 decodeAudioData() 到内存里。


3.5 decodeAudioData():把完整音频文件解码成 AudioBuffer

decodeAudioData() 用来把 ArrayBuffer 里的音频文件数据异步解码成 AudioBuffer。MDN 特别说明,它适用于完整文件数据,不适合只传音频文件片段;返回值现在主要使用 Promise。(MDN Web Docs)

典型流程:

const file = input.files?.[0];
if (!file) return;

const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);

重要区别:

MP3 / AAC / WAV 文件
  ↓ decodeAudioData
AudioBuffer
  ↓ AudioBufferSourceNode
播放 / 处理 / 混音

但它不是万能解码器。比如:

MP4 文件里有视频 + 音频

这种情况下,decodeAudioData() 不负责解析 MP4 容器,也不负责拆音轨。你通常需要先 demux 出音频轨,再考虑音频解码。这个点和前面 WebCodecs 那几章是连着的。


3.6 AudioBufferSourceNode:播放 AudioBuffer 的源节点

AudioBuffer 本身只是数据,不能自己播放。你需要创建一个 AudioBufferSourceNode

const source = audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioCtx.destination);
source.start();

非常重要的坑:

AudioBufferSourceNode 只能 start() 一次。

MDN 明确说明,AudioBufferSourceNode 每次播放后就不能再次播放;如果想重复播放同一个声音,需要复用 AudioBuffer,但重新创建新的 AudioBufferSourceNode。(MDN Web Docs)

错误写法:

source.start();
source.stop();
source.start(); // ❌ 会报错

正确写法:

function play(buffer: AudioBuffer) {
  const source = audioCtx.createBufferSource();
  source.buffer = buffer;
  source.connect(audioCtx.destination);
  source.start();
}

AudioBuffer 像唱片,AudioBufferSourceNode 像一次性唱针。唱片可以重复用,但每次播放要换一个唱针。


3.7 MediaElementAudioSourceNode:把 <audio> / <video> 接进 Web Audio

有时候你不想手动解码整个音频文件,只想让 <audio> 标签负责加载、播放、seek、缓冲,然后把它的声音接到 Web Audio 里做音量、滤波、可视化。

这时用:

const audio = document.querySelector("audio")!;
const source = audioCtx.createMediaElementSource(audio);

MediaElementAudioSourceNode 表示来自 HTML <audio><video> 元素的音频源,它没有输入,只有一个输出,由 AudioContext.createMediaElementSource() 创建。(MDN Web Docs)

典型 graph:

<audio> element

MediaElementAudioSourceNode

GainNode

AnalyserNode

destination

这种方式很适合:

场景原因
音乐播放器可视化<audio> 负责播放,AnalyserNode 负责分析
视频声音处理<video> 音轨进入 Web Audio
在线播放器 EQBiquadFilterNode 做均衡器
播放控制复杂的长音频用 media element 处理缓冲和 seek

3.8 MediaStreamAudioSourceNode:接入麦克风 / 摄像头音频流

如果你要处理麦克风声音:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const micSource = audioCtx.createMediaStreamSource(stream);

然后可以接:

麦克风 MediaStream

MediaStreamAudioSourceNode

GainNode

AnalyserNode

destination / MediaStreamDestination / AudioWorkletNode

常见用途:

场景用法
录音音量检测AnalyserNode
麦克风实时波形getByteTimeDomainData()
语音活动检测 VADAudioWorklet 或 Worker + PCM
实时变声AudioWorkletNode
麦克风混背景音乐多个 source 接同一个 master gain

3.9 GainNode:音量控制

GainNode 的作用是把音频 sample 乘以一个系数:

输出 sample = 输入 sample × gain

例如:

const gainNode = audioCtx.createGain();

gainNode.gain.value = 1.0; // 原音量
gainNode.gain.value = 0.5; // 振幅减半
gainNode.gain.value = 0.0; // 静音

推荐用平滑变化,不要瞬间改值,否则可能产生“啪”的爆音:

gainNode.gain.setTargetAtTime(0.5, audioCtx.currentTime, 0.01);

音量控制 graph:

source

GainNode

destination

在真实项目里,一般会有多个 gain:

trackGain,每条轨道音量

masterGain,总音量

destination

3.10 BiquadFilterNode:滤波器 / 简单 EQ

BiquadFilterNode 是常用的二阶滤波器节点,可以做低通、高通、峰值、搁架 EQ 等。

常见类型:

type作用类比
lowpass低通,保留低频,削弱高频声音变闷
highpass高通,保留高频,削弱低频去低频轰鸣
bandpass带通,只保留一段频率电话音效
notch陷波,削掉一段频率去特定噪声
lowshelf提升 / 降低低频低音旋钮
highshelf提升 / 降低高频高音旋钮
peaking提升 / 降低某个频段参数均衡器

例子:做一个低通滤波器。

const filter = audioCtx.createBiquadFilter();

filter.type = "lowpass";
filter.frequency.value = 1000; // 只让 1000Hz 附近以下更明显
filter.Q.value = 1;

source.connect(filter).connect(audioCtx.destination);

工程里做一个简单三段 EQ,大概就是:

低频 lowshelf

中频 peaking

高频 highshelf

3.11 AnalyserNode:波形和频谱可视化

AnalyserNode 用来拿音频数据做可视化。MDN 的 Web Audio 文档也把可视化列为 Web Audio 的典型能力之一。(MDN Web Docs)

它常用两个方法:

analyser.getByteTimeDomainData(dataArray);
analyser.getByteFrequencyData(dataArray);

区别:

方法数据含义适合画什么
getByteTimeDomainData()时域波形波形图、示波器
getByteFrequencyData()频域能量频谱柱状图

典型 graph:

source

AnalyserNode

destination

或者:

source

GainNode

AnalyserNode

destination

AnalyserNode 通常不会改变声音本身,它更像“旁边接了一个仪表盘”。


3.12 ScriptProcessorNode:为什么不推荐

你会在很多旧教程里看到:

audioCtx.createScriptProcessor(...)

现在不建议新项目继续用它。

MDN 已经把 ScriptProcessorNode 标为 deprecated,并说明它被 AudioWorkletAudioWorkletNode 替代。(MDN Web Docs) 主要问题是它的模型偏旧,容易带来延迟、主线程压力和音频 glitch。

你现在应该这么记:

旧方案:
ScriptProcessorNode

新方案:
AudioWorklet + AudioWorkletNode + AudioWorkletProcessor

3.13 AudioWorklet:自定义实时音频处理

AudioWorklet 用于提供自定义音频处理脚本,这些脚本运行在单独的 Web Audio 线程中,适合低延迟音频处理;MDN 还标注它需要 secure context,也就是通常要在 HTTPS 环境下使用。(MDN Web Docs)

适合做:

场景为什么用 AudioWorklet
实时音频特效需要低延迟
麦克风 PCM 采集需要稳定拿到 sample
音量检测 / VAD需要实时分析
自定义合成器需要 sample-level 生成
降噪 / 变声需要持续处理音频流

最小结构:

main.ts
  ↓ addModule()
processor.js
  ↓ registerProcessor()
AudioWorkletNode
  ↓ connect graph

主线程:

const audioCtx = new AudioContext();

await audioCtx.audioWorklet.addModule("/meter-processor.js");

const meterNode = new AudioWorkletNode(audioCtx, "meter-processor");

meterNode.port.onmessage = (event) => {
  console.log("level:", event.data.level);
};

source.connect(meterNode).connect(audioCtx.destination);

meter-processor.js

class MeterProcessor extends AudioWorkletProcessor {
  process(inputs: Float32Array[][]) {
    const input = inputs[0];
    const channel = input[0];

    if (channel) {
      let sum = 0;

      for (let i = 0; i < channel.length; i++) {
        sum += channel[i] * channel[i];
      }

      const rms = Math.sqrt(sum / channel.length);

      this.port.postMessage({ level: rms });
    }

    return true;
  }
}

registerProcessor("meter-processor", MeterProcessor);

这里的 rms 可以粗略表示音量大小。实际工程里不要每个 audio block 都 postMessage,可以节流,比如每 10~20 帧发一次,否则消息太多会影响主线程。


3.14 OfflineAudioContext:离线渲染,不实时播放

OfflineAudioContext 可以理解成“不接扬声器的 AudioContext”。它不是实时播放,而是尽可能快地把音频 graph 渲染成一个 AudioBuffer。MDN 对它的说明是:它表示由 AudioNode 连接组成的音频处理图,但不像标准 AudioContext 那样渲染到硬件设备,而是尽快生成结果并输出到 AudioBuffer。(MDN Web Docs)

适合做:

场景用法
多轨混音导出多个 source 定时 start
加 fade in / fade out自动化 gain
批量音频处理不需要实时播放
导出 WAV渲染成 AudioBuffer 后写 WAV
生成预览音频离线快速合成

基本流程:

创建 OfflineAudioContext

创建 source / gain / filter 等节点

connect 到 offlineCtx.destination

source.start(time)

offlineCtx.startRendering()

得到混音后的 AudioBuffer

导出 WAV / PCM

4. 必须掌握的术语表

术语中文理解面试说法
AudioContext音频上下文 / 音频引擎管理音频图、时间、采样率、节点创建和输出
AudioNode音频节点Web Audio graph 里的基本处理单元
AudioNode graph音频路由图多个节点通过 connect 组成的音频处理链路
AudioDestinationNode输出节点通常是扬声器 / 耳机
AudioBuffer解码后的 PCM 数据存储一段内存中的多声道 PCM
AudioBufferSourceNode播放 AudioBuffer 的源节点一次性 source node,只能 start 一次
MediaElementAudioSourceNode<audio> / <video> 音频源把 HTMLMediaElement 接入 Web Audio
MediaStreamAudioSourceNode麦克风 / 流媒体音频源把 MediaStream 接入 Web Audio
GainNode音量节点对 sample 乘以 gain
BiquadFilterNode二阶滤波节点做低通、高通、EQ 等
AnalyserNode分析节点拿波形 / 频谱数据做可视化
AudioWorklet实时自定义处理机制替代 ScriptProcessorNode,适合低延迟处理
OfflineAudioContext离线音频上下文不播放,快速渲染成 AudioBuffer
decodeAudioData音频文件解码方法把完整音频文件 ArrayBuffer 解码为 AudioBuffer
PCM原始音频采样数据Web Audio 内部处理的核心数据形态

5. Demo 1:播放本地音频文件

目标

用户选择一个本地音频文件,浏览器读取文件,使用 decodeAudioData() 解码,然后通过 AudioBufferSourceNode 播放。

HTML

<input id="audioFile" type="file" accept="audio/*" />
<button id="playBtn">Play</button>
<button id="stopBtn">Stop</button>
<p id="info"></p>

TypeScript

let audioCtx: AudioContext | null = null;
let audioBuffer: AudioBuffer | null = null;
let currentSource: AudioBufferSourceNode | null = null;

const fileInput = document.querySelector<HTMLInputElement>("#audioFile")!;
const playBtn = document.querySelector<HTMLButtonElement>("#playBtn")!;
const stopBtn = document.querySelector<HTMLButtonElement>("#stopBtn")!;
const info = document.querySelector<HTMLParagraphElement>("#info")!;

async function getAudioContext(): Promise<AudioContext> {
  if (!audioCtx) {
    audioCtx = new AudioContext();
  }

  if (audioCtx.state === "suspended") {
    await audioCtx.resume();
  }

  return audioCtx;
}

fileInput.addEventListener("change", async () => {
  const file = fileInput.files?.[0];

  if (!file) return;

  const ctx = await getAudioContext();
  const arrayBuffer = await file.arrayBuffer();

  audioBuffer = await ctx.decodeAudioData(arrayBuffer);

  info.textContent = [
    `name: ${file.name}`,
    `duration: ${audioBuffer.duration.toFixed(2)}s`,
    `sampleRate: ${audioBuffer.sampleRate}`,
    `channels: ${audioBuffer.numberOfChannels}`,
  ].join(" | ");
});

playBtn.addEventListener("click", async () => {
  const ctx = await getAudioContext();

  if (!audioBuffer) {
    alert("请先选择一个音频文件");
    return;
  }

  if (currentSource) {
    try {
      currentSource.stop();
    } catch {
      // source 可能已经自然结束
    }

    currentSource.disconnect();
    currentSource = null;
  }

  const source = ctx.createBufferSource();

  source.buffer = audioBuffer;
  source.connect(ctx.destination);
  source.start();

  currentSource = source;

  source.onended = () => {
    if (currentSource === source) {
      currentSource.disconnect();
      currentSource = null;
    }
  };
});

stopBtn.addEventListener("click", () => {
  if (!currentSource) return;

  try {
    currentSource.stop();
  } catch {
    // 已结束则忽略
  }

  currentSource.disconnect();
  currentSource = null;
});

关键点

这里最重要的不是播放,而是理解这条链:

File
  ↓ file.arrayBuffer()
ArrayBuffer
  ↓ audioCtx.decodeAudioData()
AudioBuffer
  ↓ AudioBufferSourceNode
AudioNode graph
  ↓ destination
扬声器

AudioBufferSourceNode 每次播放都要新建,不能复用旧的 source。


6. Demo 2:音量控制

目标

在 source 和 destination 之间插入一个 GainNode

AudioBufferSourceNode

GainNode

AudioDestinationNode

HTML

<input id="volumeFile" type="file" accept="audio/*" />
<button id="volumePlayBtn">Play</button>
<label>
  Volume
  <input id="volumeSlider" type="range" min="0" max="2" step="0.01" value="1" />
</label>

TypeScript

const volumeFile = document.querySelector<HTMLInputElement>("#volumeFile")!;
const volumePlayBtn = document.querySelector<HTMLButtonElement>("#volumePlayBtn")!;
const volumeSlider = document.querySelector<HTMLInputElement>("#volumeSlider")!;

let volumeCtx: AudioContext | null = null;
let volumeBuffer: AudioBuffer | null = null;
let gainNode: GainNode | null = null;

async function getVolumeContext(): Promise<AudioContext> {
  if (!volumeCtx) {
    volumeCtx = new AudioContext();
    gainNode = volumeCtx.createGain();
    gainNode.gain.value = Number(volumeSlider.value);
    gainNode.connect(volumeCtx.destination);
  }

  if (volumeCtx.state === "suspended") {
    await volumeCtx.resume();
  }

  return volumeCtx;
}

volumeFile.addEventListener("change", async () => {
  const file = volumeFile.files?.[0];

  if (!file) return;

  const ctx = await getVolumeContext();
  volumeBuffer = await ctx.decodeAudioData(await file.arrayBuffer());
});

volumeSlider.addEventListener("input", async () => {
  const ctx = await getVolumeContext();

  if (!gainNode) return;

  const value = Number(volumeSlider.value);

  // 比直接 gain.value = value 更平滑,减少点击爆音
  gainNode.gain.setTargetAtTime(value, ctx.currentTime, 0.01);
});

volumePlayBtn.addEventListener("click", async () => {
  const ctx = await getVolumeContext();

  if (!volumeBuffer || !gainNode) {
    alert("请先选择音频文件");
    return;
  }

  const source = ctx.createBufferSource();

  source.buffer = volumeBuffer;
  source.connect(gainNode);
  source.start();
});

关键点

GainNode 不是“播放器音量条”,它本质是对 PCM sample 乘一个值:

gain = 1.0  原始振幅
gain = 0.5  振幅减半
gain = 0.0  静音
gain = 2.0  振幅加倍,可能削波失真

真实工程里建议加一个 master gain:

track1 source → track1 gain ┐
track2 source → track2 gain ├→ master gain → destination
track3 source → track3 gain ┘

7. Demo 3:频谱可视化

目标

<audio> 负责播放,用 AnalyserNode 拿频谱数据,用 Canvas 画柱状图。

HTML

<audio id="player" controls src="/demo.mp3"></audio>
<canvas id="spectrum" width="800" height="240"></canvas>
<button id="visualizeBtn">Start Visualize</button>

TypeScript

const audio = document.querySelector<HTMLAudioElement>("#player")!;
const canvas = document.querySelector<HTMLCanvasElement>("#spectrum")!;
const button = document.querySelector<HTMLButtonElement>("#visualizeBtn")!;
const canvasCtx = canvas.getContext("2d")!;

let visualCtx: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let initialized = false;

button.addEventListener("click", async () => {
  if (!visualCtx) {
    visualCtx = new AudioContext();
  }

  if (visualCtx.state === "suspended") {
    await visualCtx.resume();
  }

  if (!initialized) {
    const source = visualCtx.createMediaElementSource(audio);

    analyser = visualCtx.createAnalyser();
    analyser.fftSize = 2048;

    source.connect(analyser);
    analyser.connect(visualCtx.destination);

    initialized = true;
    drawSpectrum();
  }

  await audio.play();
});

function drawSpectrum() {
  if (!analyser) return;

  const bufferLength = analyser.frequencyBinCount;
  const dataArray = new Uint8Array(bufferLength);

  const draw = () => {
    requestAnimationFrame(draw);

    analyser!.getByteFrequencyData(dataArray);

    canvasCtx.clearRect(0, 0, canvas.width, canvas.height);

    const barWidth = canvas.width / bufferLength;
    let x = 0;

    for (let i = 0; i < bufferLength; i++) {
      const value = dataArray[i];
      const barHeight = (value / 255) * canvas.height;

      canvasCtx.fillRect(
        x,
        canvas.height - barHeight,
        barWidth,
        barHeight,
      );

      x += barWidth;
    }
  };

  draw();
}

关键点

这条链路是:

<audio>

MediaElementAudioSourceNode

AnalyserNode

destination

AnalyserNode 一边让声音继续往后走,一边让你读取频谱数据。它不是“录音器”,也不是“编码器”,只是分析节点。

一个常见坑是:不要反复对同一个 <audio> 元素调用 createMediaElementSource()。在组件化框架里,比如 React,要把 source node 缓存起来,否则很容易遇到重复绑定的问题。


8. Demo 4:两段音频混音并导出 WAV

目标

用户选择两段音频,把它们混在一起,并导出 WAV。

这里使用:

AudioContext
  ↓ decodeAudioData
AudioBuffer A / B

OfflineAudioContext

AudioBufferSourceNode A / B

GainNode A / B

offlineCtx.destination
  ↓ startRendering()
混音后的 AudioBuffer

手写 WAV

HTML

<input id="mixFileA" type="file" accept="audio/*" />
<input id="mixFileB" type="file" accept="audio/*" />
<button id="mixBtn">Mix And Export WAV</button>
<a id="downloadLink">Download WAV</a>

TypeScript

const mixFileA = document.querySelector<HTMLInputElement>("#mixFileA")!;
const mixFileB = document.querySelector<HTMLInputElement>("#mixFileB")!;
const mixBtn = document.querySelector<HTMLButtonElement>("#mixBtn")!;
const downloadLink = document.querySelector<HTMLAnchorElement>("#downloadLink")!;

mixBtn.addEventListener("click", async () => {
  const fileA = mixFileA.files?.[0];
  const fileB = mixFileB.files?.[0];

  if (!fileA || !fileB) {
    alert("请选择两段音频");
    return;
  }

  const decodeCtx = new AudioContext();

  const [bufferA, bufferB] = await Promise.all([
    decodeFile(fileA, decodeCtx),
    decodeFile(fileB, decodeCtx),
  ]);

  const sampleRate = decodeCtx.sampleRate;

  // 例子:A 从 0 秒开始,B 从 2 秒开始
  const startA = 0;
  const startB = 2;

  const outputDuration = Math.max(
    startA + bufferA.duration,
    startB + bufferB.duration,
  );

  const outputChannels = 2;
  const outputLength = Math.ceil(outputDuration * sampleRate);

  const offlineCtx = new OfflineAudioContext(
    outputChannels,
    outputLength,
    sampleRate,
  );

  const sourceA = new AudioBufferSourceNode(offlineCtx, {
    buffer: bufferA,
  });

  const sourceB = new AudioBufferSourceNode(offlineCtx, {
    buffer: bufferB,
  });

  const gainA = new GainNode(offlineCtx, {
    gain: 0.9,
  });

  const gainB = new GainNode(offlineCtx, {
    gain: 0.7,
  });

  sourceA.connect(gainA).connect(offlineCtx.destination);
  sourceB.connect(gainB).connect(offlineCtx.destination);

  sourceA.start(startA);
  sourceB.start(startB);

  const mixedBuffer = await offlineCtx.startRendering();

  const wavBlob = audioBufferToWav(mixedBuffer);
  const url = URL.createObjectURL(wavBlob);

  downloadLink.href = url;
  downloadLink.download = "mixed.wav";
  downloadLink.textContent = "Download mixed.wav";

  await decodeCtx.close();
});

async function decodeFile(
  file: File,
  ctx: AudioContext,
): Promise<AudioBuffer> {
  const arrayBuffer = await file.arrayBuffer();
  return ctx.decodeAudioData(arrayBuffer);
}

function audioBufferToWav(buffer: AudioBuffer): Blob {
  const numChannels = buffer.numberOfChannels;
  const sampleRate = buffer.sampleRate;
  const bytesPerSample = 2;
  const blockAlign = numChannels * bytesPerSample;
  const dataSize = buffer.length * blockAlign;
  const headerSize = 44;

  const arrayBuffer = new ArrayBuffer(headerSize + dataSize);
  const view = new DataView(arrayBuffer);

  writeString(view, 0, "RIFF");
  view.setUint32(4, 36 + dataSize, true);
  writeString(view, 8, "WAVE");

  writeString(view, 12, "fmt ");
  view.setUint32(16, 16, true); // PCM fmt chunk size
  view.setUint16(20, 1, true); // audio format: 1 = PCM
  view.setUint16(22, numChannels, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * blockAlign, true); // byte rate
  view.setUint16(32, blockAlign, true);
  view.setUint16(34, bytesPerSample * 8, true);

  writeString(view, 36, "data");
  view.setUint32(40, dataSize, true);

  const channelData: Float32Array[] = [];

  for (let channel = 0; channel < numChannels; channel++) {
    channelData.push(buffer.getChannelData(channel));
  }

  let offset = 44;

  for (let i = 0; i < buffer.length; i++) {
    for (let channel = 0; channel < numChannels; channel++) {
      const sample = clamp(channelData[channel][i], -1, 1);

      const int16 =
        sample < 0
          ? sample * 0x8000
          : sample * 0x7fff;

      view.setInt16(offset, int16, true);
      offset += 2;
    }
  }

  return new Blob([arrayBuffer], {
    type: "audio/wav",
  });
}

function writeString(view: DataView, offset: number, value: string): void {
  for (let i = 0; i < value.length; i++) {
    view.setUint8(offset + i, value.charCodeAt(i));
  }
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value));
}

关键点

这个 Demo 里导出的是:

WAV = RIFF header + PCM data

不是 MP3,也不是 AAC。

Web Audio 本身负责:

解码到 AudioBuffer
音量调整
混音
离线渲染
得到 PCM

但如果你要导出 MP3 / AAC,通常还需要额外编码器,例如浏览器的 MediaRecorder、WebCodecs 音频编码能力,或者 WASM 编码库。


9. 录制 Web Audio graph 的思路

Web Audio 可以把 graph 的输出接到 MediaStreamAudioDestinationNode,再交给 MediaRecorder 录制。

概念图:

source A ┐
source B ├→ masterGain → MediaStreamAudioDestinationNode → MediaRecorder
mic      ┘

代码骨架:

const audioCtx = new AudioContext();

const masterGain = audioCtx.createGain();
const streamDestination = audioCtx.createMediaStreamDestination();

masterGain.connect(streamDestination);

const recorder = new MediaRecorder(streamDestination.stream);
const chunks: BlobPart[] = [];

recorder.ondataavailable = (event) => {
  if (event.data.size > 0) {
    chunks.push(event.data);
  }
};

recorder.onstop = () => {
  const blob = new Blob(chunks, {
    type: recorder.mimeType,
  });

  const url = URL.createObjectURL(blob);
  console.log(url);
};

recorder.start();

// 把你的 source 接到 masterGain
// source.connect(masterGain)

注意:

Web Audio graph → MediaStream → MediaRecorder

这条链可以得到浏览器支持的录制格式,但具体能录成什么 MIME type,要看浏览器支持情况。


10. Web Audio 和 WebCodecs 的关系

你前面刚学完 WebCodecs,所以这里要把边界讲清楚。

问题Web AudioWebCodecs
处理对象PCM 音频、AudioBuffer、AudioNode graphEncodedAudioChunk、AudioData、EncodedVideoChunk、VideoFrame
擅长播放、混音、滤波、可视化、实时音频图编码、解码、逐帧 / 逐 chunk 处理
是否处理容器不处理 MP4 / WebM 容器也不处理容器
是否适合实时音频效果适合,尤其 AudioWorklet不主要做 graph 效果
是否能导出文件可导出 PCM / WAV,或接 MediaRecorder编码后仍需 muxer 才是标准文件

一句话总结:

WebCodecs 更靠近 codec。
Web Audio 更靠近 audio engine / mixer / effects graph。

典型浏览器音视频编辑器会同时用到:

MP4 demuxer

WebCodecs 解码视频

Canvas / WebGL 处理视频帧

音频轨

Web Audio 解码 / 混音 / 可视化

导出 PCM / WAV 或重新编码

muxer 封装成文件

11. 和真实工程的关系

Web Audio 在前端音视频岗位里非常实用,尤其是这些业务:

业务Web Audio 用在哪里
在线剪辑器多轨音频混音、fade in / fade out、导出
播客编辑器波形、静音检测、音量归一、剪切
K 歌 / 直播麦克风采集、混伴奏、监听、可视化
视频编辑器替换 BGM、调整原声、音画预览
语音 AI 产品麦克风 PCM 采集、音量检测、VAD
音乐播放器频谱、EQ、可视化
游戏音效调度、空间音频、低延迟播放

面试里你不用把自己讲成音频算法专家,但要能说清:

我知道 Web Audio 的核心是 AudioContext + AudioNode graph。
我可以把不同 source 接入 graph,用 GainNode 控制音量,用 BiquadFilterNode 做滤波,用 AnalyserNode 做可视化。
如果要做离线混音导出,我会用 OfflineAudioContext。
如果要做低延迟自定义处理,我会考虑 AudioWorklet,而不是 ScriptProcessorNode。

这就很够打了。


12. 常见误区

误区 1:Web Audio 就是播放音频

不对。

播放只是最基础能力。Web Audio 更重要的是:

routing
mixing
filtering
analysis
scheduling
offline rendering
custom processing

误区 2:AudioBufferSourceNode 可以暂停后继续播放

不太对。

AudioBufferSourceNode 是一次性播放节点,没有内置 pause / resume。要做暂停继续,一般要自己记录:

播放开始时刻
暂停时 currentTime
已播放 offset
再次播放时从 offset 开始 source.start(when, offset)

误区 3:同一个 AudioBufferSourceNode 可以重复 start

错。

它只能 start 一次。复用的是 AudioBuffer,不是 source node。


误区 4:decodeAudioData() 可以流式解码任意片段

不对。

它适合完整音频文件数据,不适合你随便切一段 compressed bytes 就丢进去解码。MDN 对这个限制说得很清楚:它只适用于 complete file data,不适用于音频文件片段。(MDN Web Docs)


误区 5:Web Audio 可以直接导出 MP3

不对。

Web Audio 输出的是 PCM / AudioBuffer。你可以自己写 WAV,因为 WAV PCM 很简单;但 MP3 / AAC 是编码格式,需要编码器。


误区 6:AnalyserNode 会改变声音

通常不会。

它主要是让你读取波形 / 频谱数据,像仪表盘,不是效果器。


误区 7:ScriptProcessorNode 还能用,所以新项目继续用它也没问题

不建议。

它已经 deprecated,MDN 明确建议避免使用并更新现有代码,替代方向是 AudioWorklet。(MDN Web Docs)


13. 面试可能怎么问

Q1:Web Audio API 的核心模型是什么?

简洁回答:

Web Audio 的核心是 AudioContext 和由多个 AudioNode 连接成的 audio graph。音频从 source node 出来,经过 gain、filter、analyser 等 processing node,最后到 destination 输出。

深入回答:

AudioContext 管理音频时钟、采样率、节点创建和输出。每个 AudioNode 有输入和输出,可以通过 connect() 组成路由图。这个模型适合做播放、混音、滤波、可视化、实时处理和离线渲染。


Q2:AudioBufferAudioBufferSourceNode 有什么区别?

简洁回答:

AudioBuffer 是解码后的 PCM 数据;AudioBufferSourceNode 是把这段数据接入 Web Audio graph 并播放的 source node。

深入回答:

AudioBuffer 可以复用,但 AudioBufferSourceNode 只能 start() 一次。每次播放同一个 buffer,都应该新建一个 source node。


Q3:Web Audio 和 <audio> 标签有什么区别?

简洁回答:

<audio> 更像播放器,负责加载、播放、暂停、seek;Web Audio 更像音频处理引擎,可以做混音、滤波、分析、可视化和离线渲染。

深入回答:

两者可以结合使用。比如用 <audio> 播放长音频,再用 createMediaElementSource() 把它接入 Web Audio,用 AnalyserNode 画频谱,或用 BiquadFilterNode 做 EQ。


Q4:为什么不推荐 ScriptProcessorNode

简洁回答:

因为它已经 deprecated,推荐用 AudioWorklet 替代。

深入回答:

ScriptProcessorNode 是旧的 JS 音频处理方式,容易有延迟和 glitch。AudioWorklet 的处理代码运行在 Web Audio 相关线程里,更适合低延迟实时音频处理。


Q5:OfflineAudioContext 用来做什么?

简洁回答:

它用于离线渲染音频 graph,不播放到扬声器,而是快速生成一个 AudioBuffer

深入回答:

比如多轨混音导出:创建 OfflineAudioContext,把多个 AudioBufferSourceNode 按时间接进去,加 gain、filter、fade,然后调用 startRendering(),得到最终混音后的 AudioBuffer,再导出 WAV 或交给编码器。


Q6:Web Audio 能不能直接生成 MP4?

简洁回答:

不能。Web Audio 不负责容器封装。

深入回答:

Web Audio 处理的是音频 PCM、AudioBuffer 和 AudioNode graph。要生成 MP4,需要音频编码器把 PCM 编成 AAC / Opus 等,再用 muxer 封装进 MP4 / WebM。Web Audio 可以负责音频处理,但不能替代 muxer。


14. 实践任务

任务 1:本地音频播放器

实现:

上传音频

decodeAudioData

显示 duration / sampleRate / channels

播放 / 停止

重点检查:

是否每次播放都新建 AudioBufferSourceNode

任务 2:音量控制 + 静音按钮

实现:

source

GainNode

destination

功能:

音量 slider:0 ~ 2
Mute 按钮
恢复音量

进阶:

用 setTargetAtTime 平滑变化,避免爆音

任务 3:频谱可视化

实现:

<audio>

MediaElementAudioSourceNode

AnalyserNode

Canvas

要求:

画频谱柱状图
画时域波形
切换 fftSize

任务 4:麦克风音量检测

实现:

getUserMedia({ audio: true })

MediaStreamAudioSourceNode

AnalyserNode

计算 RMS / peak

展示:

实时音量条
静音判断
说话检测

任务 5:两段音频离线混音导出 WAV

实现:

上传 A / B
设置 B 的开始时间
设置各自音量
OfflineAudioContext 渲染
导出 WAV

进阶:

给 A 加 fade out
给 B 加 fade in

15. 本章总结

这一章你要抓住一条主线:

AudioContext 是音频运行时
AudioNode 是处理单元
connect() 组成音频图
source 产生声音
processing node 处理声音
destination 输出声音

最核心的 graph 是:

source

gain

filter

analyser

destination

本章最重要的 API:

AudioContext
AudioNode
AudioDestinationNode
AudioBuffer
AudioBufferSourceNode
MediaElementAudioSourceNode
MediaStreamAudioSourceNode
GainNode
BiquadFilterNode
AnalyserNode
AudioWorklet
OfflineAudioContext
decodeAudioData

最重要的工程判断:

播放短音效:AudioBuffer + AudioBufferSourceNode
播放长音频:<audio> + MediaElementAudioSourceNode
实时麦克风:MediaStreamAudioSourceNode
可视化:AnalyserNode
音量控制:GainNode
滤波 / EQ:BiquadFilterNode
低延迟自定义处理:AudioWorklet
离线混音导出:OfflineAudioContext
导出 WAV:AudioBuffer → PCM → WAV header
导出 MP3 / AAC:还需要编码器

16. 自测题

1. Web Audio 的核心模型是什么?

答案:

AudioContext + AudioNode graph。音频从 source node 出来,经过 processing node,最后到 destination。


2. AudioContext.destination 是什么?

答案:

默认输出节点,通常代表系统扬声器或耳机。


3. AudioBuffer 是压缩数据还是 PCM?

答案:

是解码后的 PCM 数据,通常以 Float32 形式按声道存储。


4. AudioBufferSourceNode 能不能重复 start()

答案:

不能。它只能 start() 一次。要重复播放同一个声音,应复用 AudioBuffer,但重新创建 AudioBufferSourceNode


5. decodeAudioData() 适合解码什么?

答案:

适合把完整音频文件数据的 ArrayBuffer 解码成 AudioBuffer。不适合随便传压缩音频片段做流式解码。


6. GainNode 的本质是什么?

答案:

对输入音频 sample 乘以一个 gain 系数,用于音量控制或音量包络。


7. AnalyserNode 常用哪两个方法?

答案:

getByteTimeDomainData()
getByteFrequencyData()

前者拿时域波形,后者拿频域数据。


8. 为什么 ScriptProcessorNode 不推荐?

答案:

因为它已经 deprecated,并被 AudioWorklet / AudioWorkletNode 替代。新项目做实时自定义音频处理应优先考虑 AudioWorklet。


9. OfflineAudioContext 和普通 AudioContext 有什么区别?

答案:

普通 AudioContext 通常实时渲染到扬声器;OfflineAudioContext 不输出到硬件,而是尽快把音频 graph 渲染成一个 AudioBuffer


10. Web Audio 能直接导出 MP3 吗?

答案:

不能。Web Audio 可以处理和生成 PCM / AudioBuffer,可以手写 WAV,但 MP3 / AAC 需要额外编码器。


17. 下一章衔接

下一章是:

Chapter 10:浏览器端音频处理与混音

这一章我们学的是 Web Audio 的基础 API。下一章会把这些 API 组合成一个更像真实项目的东西:

多音轨上传

decodeAudioData

绘制波形

设置开始时间

设置音量

fade in / fade out

OfflineAudioContext 离线混音

导出 WAV

也就是说,第九章解决的是:

每个 API 是什么

第十章解决的是:

怎么把这些 API 拼成一个浏览器端音频编辑器